import { Buffer } from 'buffer';

import type { IGetAppsFilter } from './IGetAppsFilter';
import { ProxiedApp } from './ProxiedApp';
import type { PersistenceBridge, UserBridge } from './bridges';
import { AppBridges } from './bridges';
import { AppStatus, AppStatusUtils } from '../definition/AppStatus';
import type { IAppInfo } from '../definition/metadata';
import { AppMethod } from '../definition/metadata';
import type { IPermission } from '../definition/permissions/IPermission';
import type { IUser } from '../definition/users';
import { UserType } from '../definition/users';
import type { IInternalPersistenceBridge } from './bridges/IInternalPersistenceBridge';
import type { IInternalUserBridge } from './bridges/IInternalUserBridge';
import { AppCompiler, AppFabricationFulfillment, AppPackageParser } from './compiler';
import { InvalidLicenseError } from './errors';
import { InvalidInstallationError } from './errors/InvalidInstallationError';
import {
	AppAccessorManager,
	AppApiManager,
	AppExternalComponentManager,
	AppLicenseManager,
	AppListenerManager,
	AppSchedulerManager,
	AppSettingsManager,
	AppSlashCommandManager,
	AppVideoConfProviderManager,
} from './managers';
import { AppOutboundCommunicationProviderManager } from './managers/AppOutboundCommunicationProviderManager';
import { AppRuntimeManager } from './managers/AppRuntimeManager';
import { AppSignatureManager } from './managers/AppSignatureManager';
import { UIActionButtonManager } from './managers/UIActionButtonManager';
import type { IMarketplaceInfo } from './marketplace';
import { defaultPermissions } from './permissions/AppPermissions';
import { EmptyRuntime } from './runtime/EmptyRuntime';
import type { IAppStorageItem } from './storage';
import { AppLogStorage, AppMetadataStorage } from './storage';
import { AppSourceStorage } from './storage/AppSourceStorage';
import { AppInstallationSource } from './storage/IAppStorageItem';

export interface IAppInstallParameters {
	enable: boolean;
	marketplaceInfo?: IMarketplaceInfo[];
	permissionsGranted?: Array<IPermission>;
	user: IUser;
}

export interface IAppUninstallParameters {
	user: IUser;
}

export interface IAppManagerDeps {
	metadataStorage: AppMetadataStorage;
	logStorage: AppLogStorage;
	bridges: AppBridges;
	sourceStorage: AppSourceStorage;
}

interface IPurgeAppConfigOpts {
	keepScheduledJobs?: boolean;
	keepSlashcommands?: boolean;
	keepOutboundCommunicationProviders?: boolean;
}

export class AppManager {
	public static Instance: AppManager;

	// apps contains all of the Apps
	private readonly apps: Map<string, ProxiedApp>;

	private readonly appMetadataStorage: AppMetadataStorage;

	private appSourceStorage: AppSourceStorage;

	private readonly logStorage: AppLogStorage;

	private readonly bridges: AppBridges;

	private readonly parser: AppPackageParser;

	private readonly compiler: AppCompiler;

	private readonly accessorManager: AppAccessorManager;

	private readonly listenerManager: AppListenerManager;

	private readonly commandManager: AppSlashCommandManager;

	private readonly apiManager: AppApiManager;

	private readonly externalComponentManager: AppExternalComponentManager;

	private readonly settingsManager: AppSettingsManager;

	private readonly licenseManager: AppLicenseManager;

	private readonly schedulerManager: AppSchedulerManager;

	private readonly uiActionButtonManager: UIActionButtonManager;

	private readonly videoConfProviderManager: AppVideoConfProviderManager;

	private readonly outboundCommunicationProviderManager: AppOutboundCommunicationProviderManager;

	private readonly signatureManager: AppSignatureManager;

	private readonly runtime: AppRuntimeManager;

	private isLoaded: boolean;

	constructor({ metadataStorage, logStorage, bridges, sourceStorage }: IAppManagerDeps) {
		// Singleton style. There can only ever be one AppManager instance
		if (typeof AppManager.Instance !== 'undefined') {
			throw new Error('There is already a valid AppManager instance');
		}

		if (metadataStorage instanceof AppMetadataStorage) {
			this.appMetadataStorage = metadataStorage;
		} else {
			throw new Error('Invalid instance of the AppMetadataStorage');
		}

		if (logStorage instanceof AppLogStorage) {
			this.logStorage = logStorage;
		} else {
			throw new Error('Invalid instance of the AppLogStorage');
		}

		if (bridges instanceof AppBridges) {
			this.bridges = bridges;
		} else {
			throw new Error('Invalid instance of the AppBridges');
		}

		if (sourceStorage instanceof AppSourceStorage) {
			this.appSourceStorage = sourceStorage;
		} else {
			throw new Error('Invalid instance of the AppSourceStorage');
		}

		this.apps = new Map<string, ProxiedApp>();

		this.parser = new AppPackageParser();
		this.compiler = new AppCompiler();
		this.accessorManager = new AppAccessorManager(this);
		this.listenerManager = new AppListenerManager(this);
		this.commandManager = new AppSlashCommandManager(this);
		this.apiManager = new AppApiManager(this);
		this.externalComponentManager = new AppExternalComponentManager();
		this.settingsManager = new AppSettingsManager(this);
		this.licenseManager = new AppLicenseManager(this);
		this.schedulerManager = new AppSchedulerManager(this);
		this.uiActionButtonManager = new UIActionButtonManager(this);
		this.videoConfProviderManager = new AppVideoConfProviderManager(this);
		this.outboundCommunicationProviderManager = new AppOutboundCommunicationProviderManager(this);
		this.signatureManager = new AppSignatureManager(this);
		this.runtime = new AppRuntimeManager(this);

		this.isLoaded = false;
		AppManager.Instance = this;
	}

	/** Gets the instance of the storage connector. */
	public getStorage(): AppMetadataStorage {
		return this.appMetadataStorage;
	}

	/** Gets the instance of the log storage connector. */
	public getLogStorage(): AppLogStorage {
		return this.logStorage;
	}

	/** Gets the instance of the App package parser. */
	public getParser(): AppPackageParser {
		return this.parser;
	}

	/** Gets the compiler instance. */
	public getCompiler(): AppCompiler {
		return this.compiler;
	}

	/** Gets the accessor manager instance. */
	public getAccessorManager(): AppAccessorManager {
		return this.accessorManager;
	}

	/** Gets the instance of the Bridge manager. */
	public getBridges(): AppBridges {
		return this.bridges;
	}

	/** Gets the instance of the listener manager. */
	public getListenerManager(): AppListenerManager {
		return this.listenerManager;
	}

	/** Gets the command manager's instance. */
	public getCommandManager(): AppSlashCommandManager {
		return this.commandManager;
	}

	public getVideoConfProviderManager(): AppVideoConfProviderManager {
		return this.videoConfProviderManager;
	}

	public getOutboundCommunicationProviderManager(): AppOutboundCommunicationProviderManager {
		return this.outboundCommunicationProviderManager;
	}

	public getLicenseManager(): AppLicenseManager {
		return this.licenseManager;
	}

	/** Gets the api manager's instance. */
	public getApiManager(): AppApiManager {
		return this.apiManager;
	}

	/** Gets the external component manager's instance. */
	public getExternalComponentManager(): AppExternalComponentManager {
		return this.externalComponentManager;
	}

	/** Gets the manager of the settings, updates and getting. */
	public getSettingsManager(): AppSettingsManager {
		return this.settingsManager;
	}

	public getSchedulerManager(): AppSchedulerManager {
		return this.schedulerManager;
	}

	public getUIActionButtonManager(): UIActionButtonManager {
		return this.uiActionButtonManager;
	}

	public getSignatureManager(): AppSignatureManager {
		return this.signatureManager;
	}

	public getRuntime(): AppRuntimeManager {
		return this.runtime;
	}

	/** Gets whether the Apps have been loaded or not. */
	public areAppsLoaded(): boolean {
		return this.isLoaded;
	}

	public setSourceStorage(storage: AppSourceStorage): void {
		this.appSourceStorage = storage;
	}

	/**
	 * Goes through the entire loading up process.
	 * Expect this to take some time, as it goes through a very
	 * long process of loading all the Apps up.
	 */
	public async load(): Promise<boolean> {
		// You can not load the AppManager system again
		// if it has already been loaded.
		if (this.isLoaded) {
			return true;
		}

		const items: Map<string, IAppStorageItem> = await this.appMetadataStorage.retrieveAll();

		for (const item of items.values()) {
			try {
				const appPackage = await this.appSourceStorage.fetch(item);
				const unpackageResult = await this.getParser().unpackageApp(appPackage);

				const app = await this.getCompiler().toSandBox(this, item, unpackageResult);

				this.apps.set(item.id, app);
			} catch (e) {
				console.warn(`Error while compiling the App "${item.info.name} (${item.id})":`);
				console.error(e);

				const prl = new ProxiedApp(this, item, new EmptyRuntime(item.id));

				this.apps.set(item.id, prl);
			}
		}

		this.isLoaded = true;
		return true;
	}

	public async enableAll(): Promise<Array<AppFabricationFulfillment>> {
		const affs: Array<AppFabricationFulfillment> = [];

		// Let's initialize them
		for (const rl of this.apps.values()) {
			const aff = new AppFabricationFulfillment();

			aff.setAppInfo(rl.getInfo());
			aff.setImplementedInterfaces(rl.getImplementationList());
			aff.setApp(rl);
			affs.push(aff);

			if (AppStatusUtils.isDisabled(await rl.getStatus())) {
				// Usually if an App is disabled before it's initialized,
				// then something (such as an error) occured while
				// it was compiled or something similar.
				// We still have to validate its license, though
				await rl.validateLicense();

				continue;
			}

			await this.initializeApp(rl, true).catch(console.error);
		}

		// Let's ensure the required settings are all set
		for (const rl of this.apps.values()) {
			if (AppStatusUtils.isDisabled(await rl.getStatus())) {
				continue;
			}

			if (!this.areRequiredSettingsSet(rl.getStorageItem())) {
				await rl.setStatus(AppStatus.INVALID_SETTINGS_DISABLED).catch(console.error);
			}
		}

		// Now let's enable the apps which were once enabled
		// but are not currently disabled.
		for (const app of this.apps.values()) {
			const status = await app.getStatus();
			if (!AppStatusUtils.isDisabled(status) && AppStatusUtils.isEnabled(app.getPreviousStatus())) {
				await this.enableApp(app).catch(console.error);
			} else if (!AppStatusUtils.isError(status)) {
				this.listenerManager.lockEssentialEvents(app);
				this.uiActionButtonManager.clearAppActionButtons(app.getID());
			}
		}

		return affs;
	}

	public async unload(isManual: boolean): Promise<void> {
		// If the AppManager hasn't been loaded yet, then
		// there is nothing to unload
		if (!this.isLoaded) {
			return;
		}

		for (const app of this.apps.values()) {
			const status = await app.getStatus();
			if (status === AppStatus.INITIALIZED) {
				await this.purgeAppConfig(app);
			} else if (!AppStatusUtils.isDisabled(status)) {
				await this.disable(app.getID(), isManual ? AppStatus.MANUALLY_DISABLED : AppStatus.DISABLED);
			}

			this.listenerManager.releaseEssentialEvents(app);

			app.getRuntimeController().stopApp();
		}

		// Remove all the apps from the system now that we have unloaded everything
		this.apps.clear();

		this.isLoaded = false;
	}

	/** Gets the Apps which match the filter passed in. */
	public async get(filter?: IGetAppsFilter): Promise<ProxiedApp[]> {
		let rls: Array<ProxiedApp> = [];

		if (typeof filter === 'undefined') {
			this.apps.forEach((rl) => rls.push(rl));

			return rls;
		}

		let nothing = true;

		if (typeof filter.enabled === 'boolean' && filter.enabled) {
			for (const rl of this.apps.values()) {
				if (AppStatusUtils.isEnabled(await rl.getStatus())) {
					rls.push(rl);
				}
			}

			nothing = false;
		}

		if (typeof filter.disabled === 'boolean' && filter.disabled) {
			for (const rl of this.apps.values()) {
				if (AppStatusUtils.isDisabled(await rl.getStatus())) {
					rls.push(rl);
				}
			}

			nothing = false;
		}

		if (nothing) {
			this.apps.forEach((rl) => rls.push(rl));
		}

		if (typeof filter.ids !== 'undefined') {
			rls = rls.filter((rl) => filter.ids.includes(rl.getID()));
		}

		if (typeof filter.installationSource !== 'undefined') {
			rls = rls.filter((rl) => rl.getInstallationSource() === filter.installationSource);
		}

		if (typeof filter.name === 'string') {
			rls = rls.filter((rl) => rl.getName() === filter.name);
		} else if (filter.name instanceof RegExp) {
			rls = rls.filter((rl) => (filter.name as RegExp).test(rl.getName()));
		}

		return rls;
	}

	/** Gets a single App by the id passed in. */
	public getOneById(appId: string): ProxiedApp {
		return this.apps.get(appId);
	}

	public getPermissionsById(appId: string): Array<IPermission> {
		const app = this.apps.get(appId);

		if (!app) {
			return [];
		}
		const { permissionsGranted } = app.getStorageItem();

		return permissionsGranted || defaultPermissions;
	}

	public async enable(id: string): Promise<boolean> {
		const rl = this.apps.get(id);

		if (!rl) {
			throw new Error(`No App by the id "${id}" exists.`);
		}

		const status = await rl.getStatus();

		if (AppStatusUtils.isEnabled(status)) {
			return true;
		}

		if (status === AppStatus.COMPILER_ERROR_DISABLED) {
			throw new Error('The App had compiler errors, can not enable it.');
		}

		const storageItem = await this.appMetadataStorage.retrieveOne(id);

		if (!storageItem) {
			throw new Error(`Could not enable an App with the id of "${id}" as it doesn't exist.`);
		}

		const isSetup = await this.runStartUpProcess(storageItem, rl, false);

		return isSetup;
	}

	public async disable(id: string, status: AppStatus = AppStatus.DISABLED, silent?: boolean): Promise<boolean> {
		if (!AppStatusUtils.isDisabled(status)) {
			throw new Error('Invalid disabled status');
		}

		const app = this.apps.get(id);

		if (!app) {
			throw new Error(`No App by the id "${id}" exists.`);
		}

		if (AppStatusUtils.isEnabled(await app.getStatus())) {
			await app.call(AppMethod.ONDISABLE).catch((e) => console.warn('Error while disabling:', e));
		}

		await this.purgeAppConfig(app, {
			keepScheduledJobs: true,
			keepSlashcommands: true,
			keepOutboundCommunicationProviders: true,
		});

		await app.setStatus(status, silent);

		const storageItem = await this.appMetadataStorage.retrieveOne(id);

		app.getStorageItem().marketplaceInfo = storageItem.marketplaceInfo;
		await app.validateLicense().catch(() => {});

		return true;
	}

	public async migrate(id: string): Promise<boolean> {
		const app = this.apps.get(id);

		if (!app) {
			throw new Error(`No App by the id "${id}" exists.`);
		}

		await app.call(AppMethod.ONUPDATE).catch((e) => console.warn('Error while migrating:', e));

		await this.purgeAppConfig(app, { keepScheduledJobs: true });

		const storageItem = await this.appMetadataStorage.retrieveOne(id);

		app.getStorageItem().marketplaceInfo = storageItem.marketplaceInfo;
		await app.validateLicense().catch(() => {});

		storageItem.migrated = true;
		storageItem.signature = await this.getSignatureManager().signApp(storageItem);

		const { marketplaceInfo, signature, migrated, _id } = storageItem;
		const stored = await this.appMetadataStorage.updatePartialAndReturnDocument({ marketplaceInfo, signature, migrated, _id });

		await this.updateLocal(stored, app);
		await this.bridges
			.getAppActivationBridge()
			.doAppUpdated(app)
			.catch(() => {});

		return true;
	}

	public async addLocal(appId: string): Promise<void> {
		const storageItem = await this.appMetadataStorage.retrieveOne(appId);

		if (!storageItem) {
			throw new Error(`App with id ${appId} couldn't be found`);
		}

		const appPackage = await this.appSourceStorage.fetch(storageItem);

		if (!appPackage) {
			throw new Error(`Package file for app "${storageItem.info.name}" (${appId}) couldn't be found`);
		}

		const parsedPackage = await this.getParser().unpackageApp(appPackage);
		const app = await this.getCompiler().toSandBox(this, storageItem, parsedPackage);

		this.apps.set(app.getID(), app);

		await this.loadOne(appId);
	}

	public async add(appPackage: Buffer, installationParameters: IAppInstallParameters): Promise<AppFabricationFulfillment> {
		const { enable = true, marketplaceInfo, permissionsGranted, user } = installationParameters;

		const aff = new AppFabricationFulfillment();
		const result = await this.getParser().unpackageApp(appPackage);
		const undoSteps: Array<() => void> = [];

		aff.setAppInfo(result.info);
		aff.setImplementedInterfaces(result.implemented.getValues());

		const descriptor: IAppStorageItem = {
			id: result.info.id,
			info: result.info,
			status: enable ? AppStatus.MANUALLY_ENABLED : AppStatus.MANUALLY_DISABLED,
			settings: {},
			implemented: result.implemented.getValues(),
			installationSource: marketplaceInfo ? AppInstallationSource.MARKETPLACE : AppInstallationSource.PRIVATE,
			marketplaceInfo,
			permissionsGranted,
			languageContent: result.languageContent,
		};

		try {
			descriptor.sourcePath = await this.appSourceStorage.store(descriptor, appPackage);

			undoSteps.push(() => this.appSourceStorage.remove(descriptor));
		} catch (error) {
			aff.setStorageError('Failed to store app package');

			return aff;
		}

		let app: ProxiedApp;

		try {
			app = await this.getCompiler().toSandBox(this, descriptor, result);
		} catch (error) {
			await Promise.all(undoSteps.map((undoer) => undoer()));

			throw error;
		}

		undoSteps.push(() =>
			this.getRuntime()
				.stopRuntime(app.getRuntimeController())
				.catch(() => {}),
		);

		// Create a user for the app
		try {
			await this.createAppUser(result.info);

			undoSteps.push(() => this.removeAppUser(app));
		} catch (err) {
			aff.setAppUserError({
				username: `${result.info.nameSlug}.bot`,
				message: 'Failed to create an app user for this app.',
			});

			await Promise.all(undoSteps.map((undoer) => undoer()));

			return aff;
		}

		descriptor.signature = await this.getSignatureManager().signApp(descriptor);
		const created = await this.appMetadataStorage.create(descriptor);

		if (!created) {
			aff.setStorageError('Failed to create the App, the storage did not return it.');

			await Promise.all(undoSteps.map((undoer) => undoer()));

			return aff;
		}

		app.getStorageItem()._id = created._id;

		this.apps.set(app.getID(), app);
		aff.setApp(app);

		// Let everyone know that the App has been added
		await this.bridges
			.getAppActivationBridge()
			.doAppAdded(app)
			.catch(() => {
				// If an error occurs during this, oh well.
			});

		await this.installApp(app, user);

		// Should enable === true, then we go through the entire start up process
		// Otherwise, we only initialize it.
		if (enable) {
			// Start up the app
			await this.runStartUpProcess(created, app, false);
		} else {
			await this.initializeApp(app);
		}

		return aff;
	}

	/**
	 * Uninstalls specified app from the server and remove
	 * all database records regarding it
	 *
	 * @returns the instance of the removed ProxiedApp
	 */
	public async remove(id: string, uninstallationParameters: IAppUninstallParameters): Promise<ProxiedApp> {
		const app = this.apps.get(id);
		const { user } = uninstallationParameters;

		// First remove the app
		await this.uninstallApp(app, user);
		await this.removeLocal(id);

		// Then let everyone know that the App has been removed
		await this.bridges.getAppActivationBridge().doAppRemoved(app).catch();

		return app;
	}

	/**
	 * Removes the app instance from the local Apps container
	 * and every type of data associated with it
	 */
	public async removeLocal(id: string): Promise<void> {
		const app = this.apps.get(id);

		if (AppStatusUtils.isEnabled(await app.getStatus())) {
			await this.disable(id);
		}

		await this.purgeAppConfig(app);
		this.listenerManager.releaseEssentialEvents(app);
		await this.removeAppUser(app);
		await (this.bridges.getPersistenceBridge() as IInternalPersistenceBridge & PersistenceBridge).purge(app.getID());
		await this.appMetadataStorage.remove(app.getID());
		await this.appSourceStorage.remove(app.getStorageItem()).catch(() => {});

		// Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch
		await this.getRuntime()
			.stopRuntime(app.getRuntimeController())
			.catch(() => {});

		this.apps.delete(app.getID());
	}

	public async update(
		appPackage: Buffer,
		permissionsGranted: Array<IPermission>,
		updateOptions: { loadApp?: boolean; user?: IUser } = { loadApp: true },
	): Promise<AppFabricationFulfillment> {
		const aff = new AppFabricationFulfillment();
		const result = await this.getParser().unpackageApp(appPackage);

		aff.setAppInfo(result.info);
		aff.setImplementedInterfaces(result.implemented.getValues());

		const old = await this.appMetadataStorage.retrieveOne(result.info.id);

		if (!old) {
			throw new Error('Can not update an App that does not currently exist.');
		}

		// If there is any error during disabling, it doesn't really matter
		await this.disable(old.id).catch(() => {});

		const descriptor: IAppStorageItem = {
			...old,
			id: result.info.id,
			info: result.info,
			languageContent: result.languageContent,
			implemented: result.implemented.getValues(),
		};

		if (!permissionsGranted) {
			delete descriptor.permissionsGranted;
		} else {
			descriptor.permissionsGranted = permissionsGranted;
		}

		try {
			descriptor.sourcePath = await this.appSourceStorage.update(descriptor, appPackage);
		} catch (error) {
			aff.setStorageError('Failed to storage app package');

			return aff;
		}

		descriptor.signature = await this.signatureManager.signApp(descriptor);
		const stored = await this.appMetadataStorage.updatePartialAndReturnDocument(descriptor, {
			unsetPermissionsGranted: typeof permissionsGranted === 'undefined',
		});

		// Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch
		await this.getRuntime()
			.stopRuntime(this.apps.get(old.id).getRuntimeController())
			.catch(() => {});

		const app = await this.getCompiler().toSandBox(this, descriptor, result);

		// Ensure there is an user for the app
		try {
			await this.createAppUser(result.info);
		} catch (err) {
			aff.setAppUserError({
				username: `${result.info.nameSlug}.bot`,
				message: 'Failed to create an app user for this app.',
			});

			return aff;
		}

		aff.setApp(app);

		if (updateOptions.loadApp) {
			const shouldEnableApp = AppStatusUtils.isEnabled(old.status);
			if (shouldEnableApp) {
				await this.updateAndStartupLocal(stored, app);
			} else {
				await this.updateAndInitializeLocal(stored, app);
			}

			await this.bridges
				.getAppActivationBridge()
				.doAppUpdated(app)
				.catch(() => {});
		}

		await this.updateApp(app, updateOptions.user, old.info.version);

		return aff;
	}

	/**
	 * Updates the local instance of an app.
	 *
	 * If the second parameter is a Buffer of an app package,
	 * unpackage and instantiate the app's main class
	 *
	 * With an instance of a ProxiedApp, start it up and replace
	 * the reference in the local app collection
	 */
	async updateLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer): Promise<ProxiedApp> {
		const app = await (async () => {
			if (appPackageOrInstance instanceof Buffer) {
				const parseResult = await this.getParser().unpackageApp(appPackageOrInstance);

				// Errors here don't really prevent the process from dying, so we don't really need to do anything on the catch
				await this.getRuntime()
					.stopRuntime(this.apps.get(stored.id).getRuntimeController())
					.catch(() => {});

				return this.getCompiler().toSandBox(this, stored, parseResult);
			}

			if (appPackageOrInstance instanceof ProxiedApp) {
				return appPackageOrInstance;
			}
		})();

		// We don't keep slashcommands here as the update could potentially not provide the same list
		await this.purgeAppConfig(app, { keepScheduledJobs: true });

		this.apps.set(app.getID(), app);
		return app;
	}

	public async updateAndStartupLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer) {
		const app = await this.updateLocal(stored, appPackageOrInstance);
		await this.runStartUpProcess(stored, app, true);
	}

	public async updateAndInitializeLocal(stored: IAppStorageItem, appPackageOrInstance: ProxiedApp | Buffer) {
		const app = await this.updateLocal(stored, appPackageOrInstance);
		await this.initializeApp(app, true);
	}

	public getLanguageContent(): { [key: string]: object } {
		const langs: { [key: string]: object } = {};

		this.apps.forEach((rl) => {
			const content = rl.getStorageItem().languageContent;

			Object.keys(content).forEach((key) => {
				langs[key] = Object.assign(langs[key] || {}, content[key]);
			});
		});

		return langs;
	}

	public async changeStatus(appId: string, status: AppStatus): Promise<ProxiedApp> {
		switch (status) {
			case AppStatus.MANUALLY_DISABLED:
			case AppStatus.MANUALLY_ENABLED:
				break;
			default:
				throw new Error('Invalid status to change an App to, must be manually disabled or enabled.');
		}

		const rl = this.apps.get(appId);

		if (!rl) {
			throw new Error('Can not change the status of an App which does not currently exist.');
		}

		const storageItem = await rl.getStorageItem();

		if (AppStatusUtils.isEnabled(status)) {
			// Then enable it
			if (AppStatusUtils.isEnabled(await rl.getStatus())) {
				throw new Error('Can not enable an App which is already enabled.');
			}

			await this.enable(rl.getID());

			storageItem.status = AppStatus.MANUALLY_ENABLED;
			await this.appMetadataStorage.updateStatus(storageItem._id, AppStatus.MANUALLY_ENABLED);
		} else {
			if (!AppStatusUtils.isEnabled(await rl.getStatus())) {
				throw new Error('Can not disable an App which is not enabled.');
			}

			await this.disable(rl.getID(), AppStatus.MANUALLY_DISABLED);

			storageItem.status = AppStatus.MANUALLY_DISABLED;
			await this.appMetadataStorage.updateStatus(storageItem._id, AppStatus.MANUALLY_DISABLED);
		}

		return rl;
	}

	public async updateAppsMarketplaceInfo(appsOverview: Array<{ latest: IMarketplaceInfo }>): Promise<void> {
		await Promise.all(
			appsOverview.map(async ({ latest: appInfo }) => {
				if (!appInfo.subscriptionInfo) {
					return;
				}

				const app = this.apps.get(appInfo.id);

				if (!app) {
					return;
				}

				const appStorageItem = app.getStorageItem();
				const { subscriptionInfo } = appStorageItem.marketplaceInfo?.[0] || {};

				if (subscriptionInfo && subscriptionInfo.license.license === appInfo.subscriptionInfo.license.license) {
					return;
				}

				appStorageItem.marketplaceInfo[0].subscriptionInfo = appInfo.subscriptionInfo;
				appStorageItem.signature = await this.getSignatureManager().signApp(appStorageItem);

				return this.appMetadataStorage.updatePartialAndReturnDocument({
					_id: appStorageItem._id,
					marketplaceInfo: appStorageItem.marketplaceInfo,
					signature: appStorageItem.signature,
				});
			}),
		).catch(() => {});

		const queue = [] as Array<Promise<void>>;

		this.apps.forEach((app) =>
			queue.push(
				app
					.validateLicense()
					.then(async () => {
						if ((await app.getStatus()) !== AppStatus.INVALID_LICENSE_DISABLED) {
							return;
						}

						return app.setStatus(AppStatus.DISABLED);
					})
					.catch(async (error) => {
						if (!(error instanceof InvalidLicenseError)) {
							console.error(error);
							return;
						}

						await this.purgeAppConfig(app, { keepScheduledJobs: true });

						return app.setStatus(AppStatus.INVALID_LICENSE_DISABLED);
					})
					.then(async () => {
						const status = await app.getStatus();
						if (status === app.getPreviousStatus()) {
							return;
						}

						const storageItem = app.getStorageItem();
						storageItem.status = status;

						return this.appMetadataStorage.updateStatus(storageItem._id, storageItem.status).catch(console.error) as Promise<void>;
					}),
			),
		);

		await Promise.all(queue);
	}

	/**
	 * Goes through the entire loading up process.
	 *
	 * @param appId the id of the application to load
	 */
	public async loadOne(appId: string, silenceStatus = false): Promise<ProxiedApp> {
		const rl = this.apps.get(appId);

		if (!rl) {
			throw new Error(`No App found by the id of: "${appId}"`);
		}

		const item = rl.getStorageItem();

		await this.initializeApp(rl, silenceStatus);

		if (!this.areRequiredSettingsSet(item)) {
			await rl.setStatus(AppStatus.INVALID_SETTINGS_DISABLED);
		}

		if (!AppStatusUtils.isDisabled(await rl.getStatus()) && AppStatusUtils.isEnabled(rl.getPreviousStatus())) {
			await this.enableApp(rl, silenceStatus);
		}

		return this.apps.get(item.id);
	}

	private async runStartUpProcess(storageItem: IAppStorageItem, app: ProxiedApp, silenceStatus: boolean): Promise<boolean> {
		if ((await app.getStatus()) !== AppStatus.INITIALIZED) {
			const isInitialized = await this.initializeApp(app, silenceStatus);
			if (!isInitialized) {
				return false;
			}
		}

		if (!this.areRequiredSettingsSet(storageItem)) {
			await app.setStatus(AppStatus.INVALID_SETTINGS_DISABLED, silenceStatus);
			return false;
		}

		return this.enableApp(app, silenceStatus);
	}

	private async installApp(app: ProxiedApp, user: IUser): Promise<boolean> {
		let result: boolean;
		const context = { user };

		try {
			await app.call(AppMethod.ONINSTALL, context);

			result = true;
		} catch (e) {
			const status = AppStatus.ERROR_DISABLED;

			result = false;

			await app.setStatus(status);
		}

		return result;
	}

	private async updateApp(app: ProxiedApp, user: IUser | null, oldAppVersion: string): Promise<boolean> {
		let result: boolean;

		try {
			await app.call(AppMethod.ONUPDATE, { oldAppVersion, user });

			result = true;
		} catch (e) {
			const status = AppStatus.ERROR_DISABLED;

			result = false;

			await app.setStatus(status);
		}

		return result;
	}

	private async initializeApp(app: ProxiedApp, silenceStatus = false): Promise<boolean> {
		let result: boolean;

		try {
			await app.validateLicense();
			await app.validateInstallation();

			await app.call(AppMethod.INITIALIZE);
			await app.setStatus(AppStatus.INITIALIZED, silenceStatus);

			await this.commandManager.registerCommands(app.getID());

			result = true;
		} catch (e) {
			let status = AppStatus.ERROR_DISABLED;

			if (e instanceof InvalidLicenseError) {
				status = AppStatus.INVALID_LICENSE_DISABLED;
			}

			if (e instanceof InvalidInstallationError) {
				status = AppStatus.INVALID_INSTALLATION_DISABLED;
			}

			await this.purgeAppConfig(app);
			result = false;

			await app.setStatus(status, silenceStatus);
		}

		return result;
	}

	private async purgeAppConfig(app: ProxiedApp, opts: IPurgeAppConfigOpts = {}) {
		if (!opts.keepScheduledJobs) {
			await this.schedulerManager.cleanUp(app.getID());
		}

		if (!opts.keepSlashcommands) {
			await this.commandManager.unregisterCommands(app.getID());
		}

		this.listenerManager.unregisterListeners(app);
		this.listenerManager.lockEssentialEvents(app);
		this.externalComponentManager.unregisterExternalComponents(app.getID());
		await this.apiManager.unregisterApis(app.getID());
		this.accessorManager.purifyApp(app.getID());
		this.uiActionButtonManager.clearAppActionButtons(app.getID());
		this.videoConfProviderManager.unregisterProviders(app.getID());
		await this.outboundCommunicationProviderManager.unregisterProviders(app.getID(), {
			keepReferences: opts.keepOutboundCommunicationProviders,
		});
	}

	/**
	 * Determines if the App's required settings are set or not.
	 * Should a packageValue be provided and not empty, then it's considered set.
	 */
	private areRequiredSettingsSet(storageItem: IAppStorageItem): boolean {
		let result = true;

		for (const setk of Object.keys(storageItem.settings)) {
			const sett = storageItem.settings[setk];
			// If it's not required, ignore
			if (!sett.required) {
				continue;
			}

			if (sett.value !== 'undefined' || sett.packageValue !== 'undefined') {
				continue;
			}

			result = false;
		}

		return result;
	}

	private async enableApp(app: ProxiedApp, silenceStatus = false): Promise<boolean> {
		let enable: boolean;
		let status = AppStatus.ERROR_DISABLED;

		try {
			await app.validateLicense();
			await app.validateInstallation();

			enable = (await app.call(AppMethod.ONENABLE)) as boolean;

			if (enable) {
				status = AppStatus.MANUALLY_ENABLED;
			} else {
				status = AppStatus.DISABLED;
				console.warn(`The App (${app.getID()}) disabled itself when being enabled. \nCheck the "onEnable" implementation for details.`);
			}
		} catch (e) {
			enable = false;

			if (e instanceof InvalidLicenseError) {
				status = AppStatus.INVALID_LICENSE_DISABLED;
			}

			if (e instanceof InvalidInstallationError) {
				status = AppStatus.INVALID_INSTALLATION_DISABLED;
			}

			console.error(e);
		}

		if (enable) {
			this.externalComponentManager.registerExternalComponents(app.getID());
			await this.apiManager.registerApis(app.getID());
			this.listenerManager.registerListeners(app);
			this.listenerManager.releaseEssentialEvents(app);
			this.videoConfProviderManager.registerProviders(app.getID());
			await this.outboundCommunicationProviderManager.registerProviders(app.getID());
		} else {
			await this.purgeAppConfig(app, {
				keepScheduledJobs: true,
				keepSlashcommands: true,
				keepOutboundCommunicationProviders: true,
			});
		}

		await app.setStatus(status, silenceStatus);

		return enable;
	}

	private async createAppUser(appInfo: IAppInfo): Promise<string> {
		const appUser = await (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).getAppUser(appInfo.id);

		if (appUser) {
			return appUser.id;
		}

		const userData: Partial<IUser> = {
			username: `${appInfo.nameSlug}.bot`,
			name: appInfo.name,
			roles: ['app'],
			appId: appInfo.id,
			type: UserType.APP,
			status: 'online',
			isEnabled: true,
		};

		return (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).create(userData, appInfo.id, {
			avatarUrl: appInfo.iconFileContent || appInfo.iconFile,
			joinDefaultChannels: true,
			sendWelcomeEmail: false,
		});
	}

	private async removeAppUser(app: ProxiedApp): Promise<boolean> {
		const appUser = await (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).getAppUser(app.getID());

		if (!appUser) {
			return true;
		}

		return (this.bridges.getUserBridge() as IInternalUserBridge & UserBridge).remove(appUser, app.getID());
	}

	private async uninstallApp(app: ProxiedApp, user: IUser): Promise<boolean> {
		let result: boolean;
		const context = { user };

		try {
			await app.call(AppMethod.ONUNINSTALL, context);

			result = true;
		} catch (e) {
			const status = AppStatus.ERROR_DISABLED;

			result = false;

			await app.setStatus(status);
		}

		return result;
	}
}

export const getPermissionsByAppId = (appId: string) => {
	if (!AppManager.Instance) {
		console.error('AppManager should be instantiated first');
		return [];
	}
	return AppManager.Instance.getPermissionsById(appId);
};
